import os
import sys
import json
import glob
import subprocess
import shutil
import argparse
import re
from openpyxl import Workbook


def load_config(config_path):
    """Loads configuration from the specified path."""
    print(f"Loading configuration from '{config_path}'...")
    try:
        with open(config_path, 'r') as f:
            config = json.load(f)
        
        required_keys = ["exclude_patterns", "build_directory", "cmake_toolchain_file"]
        for key in required_keys:
            if key not in config:
                print(f"Error: Missing required key '{key}' in {config_path}")
                sys.exit(1)

        # Resolve toolchain file path relative to the config file's location
        config_dir = os.path.dirname(os.path.abspath(config_path))
        toolchain_path = os.path.join(config_dir, config["cmake_toolchain_file"])
        if not os.path.isfile(toolchain_path):
            print(f"Error: CMake toolchain file not found at '{toolchain_path}'")
            sys.exit(1)
        config["cmake_toolchain_file"] = os.path.abspath(toolchain_path)
            
        print("Configuration loaded successfully.")
        return config
    except FileNotFoundError:
        print(f"Error: Configuration file '{config_path}' not found.")
        sys.exit(1)
    except json.JSONDecodeError:
        print(f"Error: Could not parse '{config_path}'. Please ensure it is valid JSON.")
        sys.exit(1)

def find_headers(scan_dir, exclude_patterns):
    """Finds all .h files, respecting exclusion patterns relative to the scan_dir."""
    print(f"Scanning for header files in '{scan_dir}'...")
    
    abs_exclude_patterns = [os.path.join(scan_dir, p) for p in exclude_patterns]
    
    all_headers_abs = set(glob.glob(os.path.join(scan_dir, '**/*.h'), recursive=True))
    excluded_headers_abs = set()
    for pattern in abs_exclude_patterns:
        excluded_headers_abs.update(glob.glob(pattern, recursive=True))

    final_headers_abs = sorted(list(all_headers_abs - excluded_headers_abs))
    # Return paths relative to the scan_dir for cleaner processing later
    final_headers_rel = [os.path.relpath(p, scan_dir) for p in final_headers_abs]
    
    print(f"Found {len(final_headers_rel)} headers to test.")
    return final_headers_rel

def generate_test_files(build_dir, scan_dir, headers_rel):
    """Generates .c source files for compilation testing."""
    print("Generating test source files...")
    sources_dir = os.path.join(build_dir, 'generated_sources')
    
    if os.path.exists(build_dir):
        shutil.rmtree(build_dir)
    os.makedirs(sources_dir)

    cmake_targets = []
    for header_rel_path in headers_rel:
        sanitized_name = header_rel_path.replace('/', '_').replace('.', '_').replace('-', '_')
        target_name = f"test_{sanitized_name}"
        c_filename = f"{target_name}.c"
        c_file_path = os.path.join(sources_dir, c_filename)

        header_abs_path = os.path.join(scan_dir, header_rel_path)
        header_dir = os.path.dirname(header_rel_path)
        
        with open(c_file_path, 'w') as f:
            f.write(f'#include "{header_abs_path}"\n')
        
        cmake_targets.append((target_name, os.path.join('generated_sources', c_filename), header_dir))
    return cmake_targets

def generate_cmake_lists(config, build_dir, scan_dir, targets):
    """Generates the main CMakeLists.txt file."""
    cmake_path = os.path.join(build_dir, 'CMakeLists.txt')
    extra_flags = config.get("extra_compile_flags", "")

    with open(cmake_path, 'w') as f:
        f.write('cmake_minimum_required(VERSION 3.16)\n')
        f.write('project(CHeaderPurityTest C)\n\n')
        
        # Global include for headers referenced from the project root
        f.write(f'include_directories("{scan_dir}")\n')

        # Add external include directories from config
        if config.get("external_include_dirs"):
            f.write('# --- External Include Directories ---\n')
            for include_dir in config["external_include_dirs"]:
                abs_include_dir = os.path.join(scan_dir, include_dir)
                f.write(f'include_directories("{abs_include_dir}")\n')
            f.write("\n")

        if extra_flags:
            f.write(f'add_compile_options({extra_flags})\n\n')

        f.write('# --- Test Targets (Compile Only) ---\n')
        f.write('# Using OBJECT libraries tells CMake to compile but not link.\n')
        f.write('# Using target_include_directories ensures each test has its own header\'s directory.\n')
        for target_name, c_file, header_dir in targets:
            f.write(f'add_library({target_name} OBJECT "{c_file}")\n')
            # Add the header's own directory to its include paths
            if header_dir:
                f.write(f'target_include_directories({target_name} PRIVATE "{os.path.join(scan_dir, header_dir)}")\n')

    print(f"'{os.path.join(build_dir, 'CMakeLists.txt')}' generated successfully.")

def run_build(build_dir, toolchain_file, scan_dir, headers_rel):
    """Runs CMake configure and build steps, then parses the complete log."""
    print("\n--- Starting Build Process ---")
    
    cmake_configure_cmd = [
        'cmake', '-G', 'Ninja',
        f'-DCMAKE_TOOLCHAIN_FILE={toolchain_file}',
        '.'
    ]
    
    print(f"Running CMake configuration in '{build_dir}'...")
    configure_proc = subprocess.run(cmake_configure_cmd, cwd=build_dir, capture_output=True, text=True)

    if configure_proc.returncode != 0:
        print("!!! CMake configuration failed! !!!")
        print("--- STDOUT ---"); print(configure_proc.stdout)
        print("--- STDERR ---"); print(configure_proc.stderr)
        return False, []

    print("CMake configuration successful. Starting build...")
    build_command = ['cmake', '--build', '.', '--', '-k', '0']
    build_proc = subprocess.run(build_command, cwd=build_dir, capture_output=True, text=True)

    # Combine stdout and stderr to capture all output
    full_log = build_proc.stdout + "\n" + build_proc.stderr

    # Save the full build log
    log_path = os.path.join(build_dir, "c_purity_build.log")
    with open(log_path, 'w') as f:
        f.write(full_log)

    if build_proc.returncode == 0:
        print("\n--- Build Successful ---")
        return True, []

    print("\n--- Build Failed --- (Full log saved to c_purity_build.log)")
    # Print the full log to console for immediate feedback
    print(full_log)
    
    print("\n--- Parsing Build Log ---")
    errors = []
    # Regex to strip ANSI color codes
    ansi_escape_pattern = re.compile(r'\x1B(?:[@-Z\-_]|[\[0-?]*[ -/]*[@-~])')
    # Regex to capture: file:line:column: [fatal] error: message
    error_pattern = re.compile(r"^\s*(.*?):(\d+):(\d+):\s+(?:fatal\s+)?error:\s+(.*)")

    with open(log_path, 'r') as f:
        for line in f.readlines():
            clean_line = ansi_escape_pattern.sub('', line)
            match = error_pattern.match(clean_line)
            
            if match:
                file_path, line_num, col_num, message = match.groups()
                
                original_header = "Unknown"
                for h in headers_rel:
                    if h in file_path:
                        original_header = h
                        break
                
                full_message = message.strip()
                errors.append({
                    "file": original_header,
                    "line": line_num,
                    "column": col_num,
                    "message": full_message,
                    "category": categorize_error(full_message)
                })

    print(f"Parsing complete. Found {len(errors)} errors.")
    return False, errors

def generate_excel_report(errors, output_path):
    """Generates an Excel report from a list of structured errors."""
    wb = Workbook()
    ws = wb.active
    ws.title = "C Purity Errors"

    # Write header
    headers = ["Header File", "Line", "Column", "Error Category", "Error Message"]
    ws.append(headers)

    # Write data
    for error in errors:
        ws.append([
            error["file"],
            int(error["line"]),
            int(error["column"]),
            error["category"],
            error["message"]
        ])
    
    # Adjust column widths
    for col in ws.columns:
        max_length = 0
        column = col[0].column_letter
        for cell in col:
            try:
                if len(str(cell.value)) > max_length:
                    max_length = len(cell.value)
            except:
                pass
        adjusted_width = (max_length + 2)
        ws.column_dimensions[column].width = adjusted_width

    try:
        wb.save(output_path)
        print(f"Successfully generated error report at: {output_path}")
    except Exception as e:
        print(f"Error: Could not write Excel report. {e}")

def categorize_error(error_message):
    """Categorizes a single compiler error message."""
    # List of common C++ headers (without the .h)
    cpp_headers = ['string', 'vector', 'memory', 'iostream', 'cstdint', 'algorithm', 'map', 'set']
    
    # Category: C++ Standard Library Inclusion
    if "file not found" in error_message:
        for header in cpp_headers:
            if f"'{header}' file not found" in error_message:
                return f"CRITICAL: C++ Standard Library Inclusion (<{header}>)"

    # Category: Header Not Found (for other files)
    if "file not found" in error_message or "No such file or directory" in error_message:
        return "Header Not Found"

    # Category: Missing Standard Include
    if "unknown type name 'bool'" in error_message or "undeclared identifier 'true'" in error_message:
        return "Missing Standard Include (stdbool.h)"
    if "unknown type name 'uint32_t'" in error_message or "unknown type name 'int8_t'" in error_message or \
       "unknown type name 'int64_t'" in error_message or "unknown type name 'uint64_t'" in error_message:
        return "Missing Standard Include (stdint.h)"
    if "unknown type name 'size_t'" in error_message:
        return "Missing Standard Include (stddef.h)"
    if "unknown type name 'char16_t'" in error_message:
        return "Missing Standard Include (uchar.h)"
    if "undeclared identifier 'NULL'" in error_message:
        return "Missing Standard Include (stddef.h)"

    # Category: Incorrect Type Usage
    if "must use 'struct' tag to refer to type" in error_message:
        return "Type Usage Error (Missing 'struct' keyword)"
    if "type specifier missing, defaults to 'int'" in error_message:
        return "Type Usage Error (Missing Type Specifier)"

    # Category: Incomplete Definition
    if "tentative definition has type" in error_message and "that is never completed" in error_message:
        return "Incomplete Definition (Struct)"
    if "dereferencing pointer to incomplete type" in error_message:
        return "Incomplete Definition (Dereference)"
    # This is a generic catch-all for unknown types, should be after specific cases
    if "unknown type name" in error_message:
        return "Incomplete Definition (Unknown Type)"

    # Category: Scoping/Visibility
    if "will not be visible outside of this function" in error_message:
        return "Scoping/Visibility Error"

    # Default Category
    return "Other Syntax Error"

def main():
    """Main execution function."""
    parser = argparse.ArgumentParser(
        description="C API Header Purity Test Framework. Compiles each header as a C file to check for C++ syntax.",
        formatter_class=argparse.RawTextHelpFormatter
    )
    parser.add_argument(
        "scan_directory", 
        help="The root directory of the project to scan for headers."
    )
    parser.add_argument(
        "--config", 
        default=os.path.join(os.path.dirname(__file__), 'config.json'),
        help="Path to the configuration JSON file. Defaults to 'config.json' in the script's directory."
    )
    args = parser.parse_args()

    scan_dir_abs = os.path.abspath(args.scan_directory)
    if not os.path.isdir(scan_dir_abs):
        print(f"Error: Scan directory not found at '{scan_dir_abs}'")
        sys.exit(1)

    config = load_config(args.config)
    
    headers_to_test = find_headers(scan_dir_abs, config['exclude_patterns'])
    if not headers_to_test:
        print("No headers found to test. Exiting.")
        return

    # Place build directory inside the tool's directory
    script_dir = os.path.dirname(os.path.abspath(__file__))
    build_dir_abs = os.path.join(script_dir, config['build_directory'])

    cmake_targets = generate_test_files(build_dir_abs, scan_dir_abs, headers_to_test)
    generate_cmake_lists(config, build_dir_abs, scan_dir_abs, cmake_targets)

    success, result = run_build(
        build_dir_abs, 
        config['cmake_toolchain_file'],
        scan_dir_abs,
        headers_to_test
    )

    print("\n--- Test Summary ---")
    if success:
        print(f"✅ Success! All {len(headers_to_test)} headers passed the C purity test.")
        sys.exit(0)
    else:
        errors = result
        failed_headers = set(e['file'] for e in errors)
        print(f"❌ Failure! Found {len(errors)} error(s) in {len(failed_headers)} header(s).")
        
        report_path = os.path.join(build_dir_abs, "c_purity_error_report.xlsx")
        generate_excel_report(errors, report_path)
        
        sys.exit(1)

if __name__ == "__main__":
    main()
